Python: agent-hooks interception contract as a first-class experimental core feature - #7515
Open
MohammadHaroonAbuomar wants to merge 9 commits into
Open
Conversation
Implement the AGENT-HOOKS-0.1 interception contract as a first-class experimental feature in agent_framework core. - Single public factory agent_hooks_middleware() returning a private agent/chat/function middleware trio (one object per middleware category); partial or stacked installs fail closed with loud errors. - All eight interception points: input/output at the agent seam, pre/post_model_call at the chat seam, pre/post_tool_call at the function seam, agent_startup/agent_shutdown bracketing each run. - Fail-closed enforcement throughout: transforms write back into the native contexts (messages, arguments, results) or raise; content is preserved as Content objects; MiddlewareTermination short-circuits are guarded at every seam; enforcement-layer failures halt the run; interceptor crashes surface as host_error denies. - Streaming is fully buffered per spec buffered_output semantics: no update egresses before the post_model_call/output verdicts; a deny at pull time releases zero updates; run state stays active across lazy pulls with cleanup on every exit path. - Session scoping: per-run by default (startup/shutdown bracket each run) or host-owned via emitter/builder parameters for one session spanning multiple runs. - agent-hooks-sdk is an opt-in agent-hooks extra (not in all), lazy-imported per the _mcp.py pattern; core imports cleanly without it and the factory raises a clear ModuleNotFoundError. - ExperimentalFeature.AGENT_HOOKS + @experimental decorator, lazy root export, typing surface, PACKAGE_STATUS.md entry. - 55 tests built on real Agent/mock-client flows covering deny-before- execution, transform write-back, rich-content preservation, complete streaming ordering, error cleanup, concurrency isolation, nested agents, and importability without the optional SDK. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 4, 2026 18:43 — with
GitHub Actions
Inactive
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 4, 2026 18:43 — with
GitHub Actions
Inactive
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 4, 2026 18:43 — with
GitHub Actions
Inactive
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 4, 2026 18:44 — with
GitHub Actions
Inactive
Contributor
Contributor
There was a problem hiding this comment.
Pull request overview
Adds an experimental, first-class implementation of the AGENT-HOOKS-0.1 interception/enforcement contract to the Python core package (agent-framework-core), including an opt-in extra for the SDK dependency and a comprehensive test suite validating fail-closed behavior across agent/chat/tool seams (including buffered streaming).
Changes:
- Introduces
agent_framework/_agent_hooks.pywith the publicagent_hooks_middleware(...)factory that returns an agent/chat/function middleware trio implementing all eight interception points and fail-closed semantics. - Adds the opt-in
agent-hooksextra (agent-hooks-sdk>=0.1.0a4,<0.2) and updates exports + experimental feature registration/documentation. - Adds extensive unit tests covering deny/transform semantics, streaming buffering, short-circuit guarding, partial install detection, and optional-dependency importability.
Show a summary per file
| File | Description |
|---|---|
| python/uv.lock | Adds the agent-hooks extra lock entries and locks agent-hooks-sdk 0.1.0a4. |
| python/packages/core/tests/core/test_agent_hooks.py | New test suite for agent-hooks enforcement and semantics across seams (incl. streaming). |
| python/packages/core/pyrightconfig.dependency.json | Excludes the new module from dependency-bound pyright checking. |
| python/packages/core/pyproject.toml | Adds agent-hooks optional dependency extra (explicitly not part of all). |
| python/packages/core/agent_framework/_feature_stage.py | Registers ExperimentalFeature.AGENT_HOOKS. |
| python/packages/core/agent_framework/_agent_hooks.py | Implements the enforcement middleware trio + projections/write-back + buffering semantics. |
| python/packages/core/agent_framework/init.pyi | Adds typing export for agent_hooks_middleware. |
| python/packages/core/agent_framework/init.py | Adds lazy runtime export for agent_hooks_middleware. |
| python/PACKAGE_STATUS.md | Documents the new experimental feature and its opt-in extra. |
Review details
- Files reviewed: 8/9 changed files
- Comments generated: 2
- Review effort level: Lite
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 4, 2026 19:06 — with
GitHub Actions
Inactive
The pre-commit pyupgrade hook rewrites the quoted forward reference; ResponseStream is imported at runtime in this module, so the quotes were unnecessary. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 4, 2026 19:10 — with
GitHub Actions
Inactive
Reworks the agent-hooks feature per PR review: - Verdicts now precede durability: a run-scoped persistence gate (_sessions.py) defers per-service-call history persistence and after-run provider work until the covering post_model_call/output verdict permits; denied content never persists, transforms persist post-write-back. Unhooked runs are unchanged (verified against an instrumented baseline). - ResponseStream.buffered_and_gated: a buffered-gate combinator that applies the run's pending stream hooks before the gate, then seals the stream, so no middleware can rewrite egress after the output verdict. Replaces the hand-rolled replay iterator. - MiddlewareBundle (public, _middleware.py): the factory returns an indivisible bundle categorize_middleware splits, making partial installs impossible by construction; members are validated at construction. Bare (non-sequence) middleware at agent construction is now normalized instead of silently dropped, and unrecognized middleware logs a warning instead of vanishing. - Factory split and rename: create_agent_hooks_middleware (per-run sessions) and create_agent_hooks_middleware_from_emitter (host-owned); the sentinel parameter-diffing is gone. - Wire conversions live in per-point codec classes owning to_wire and write_back. Fixes in that code: tool-call name transforms apply or raise; non-object args transforms raise; argument write-back merges only changed keys (original values, including bytes, preserved by identity); message-list write-back matches by identity, not index. - function_approval_request objects on the normal return path pass through un-emitted, preserving the human approval pause. - Hosted (service-executed) tool calls surface in the post_model_call content projection; the tool-seam limitation is documented. - Import probe covers the full SDK surface and re-raises as missing-extra only for the agent_hooks module; module logger added; _json_safe replaced by make_json_safe (which gained bytes support); tools_registered uses normalize_tools; dependency-pyright analyzes the module again via the test dependency-group. - Tests: 75 in the feature suite (persistence gating, stream-hook sealing, approval passthrough, codec units, bundle validation, bare-bundle installs), full core suite green. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 5, 2026 06:10 — with
GitHub Actions
Inactive
Evan Mattson (moonbox3)
left a comment
Contributor
There was a problem hiding this comment.
Have a look at the failing CI/CD (code quality checks) too, please. Thanks.
Addresses the second review round on the agent-hooks feature: - Nested-run persistence ownership: RawAgent.run stamps a run identity over the run's dynamic extent (including streaming pulls and result hooks); the persistence gate binds to its owning run via an offer/adopt handshake keyed to the agent instance and accepts only its owner's persists — nested runs persist inline regardless of how they were started (tool calls, middleware, custom run loops). The tool-seam suspension remains for custom-loop sub-agents invoked as tools; the one residual case (custom loop nested in a custom loop off the tool path) is fail-closed and documented. Fixes a latent pre-existing re-deferral: flush() now drains with the gate context suspended, so a nested hooked run's permitted after-run persistence no longer re-defers into an enclosing gate. - as_tool stream_callback consumes the released (verdicted) stream; observers cannot see denied or pre-transform content. Both directions are regression-tested. - categorize_middleware gained supported_categories: a bundle member landing in a category a call site cannot install raises; bare middleware warns like _add_middleware. Wired at the chat-client sites and the provider seam. - ResponseStream.buffered_and_gated owns the re-derivation rule via a rederive callable (gates cannot choose released updates) and is marked experimental. - Wire codecs compare with bool-aware equality (Python == equates 1 == True, which made bool/number transforms look untouched and get dropped) and _ToolResultCodec.write_back owns the untouched-wire rule via the before value. - middleware parameters accept a bare middleware or bundle everywhere the runtime does (constructors, run overloads, as_agent, telemetry and harness layers, foundry); the bare-source rule has a single owner in categorize_middleware; bare middleware assigned to the attribute now executes (documented behavior change). - MiddlewareBundle is experimental and validates members; approval passthrough, typing-check fixes (ty ignores mypy-coded ignore comments), logging, and documentation updates per review. Test count: 85 feature tests plus 12 new this round across sessions, middleware, agents; full core suite green; typing checked under mypy, pyrefly, ty, zuban, and pyright. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 5, 2026 09:01 — with
GitHub Actions
Inactive
Per review: docstrings describe current behavior only. The bare-middleware behavior change stays recorded in the PR description and commit history. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 5, 2026 23:36 — with
GitHub Actions
Inactive
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 5, 2026 23:37 — with
GitHub Actions
Inactive
A retry or fallback middleware issuing a second call_next() gave the new attempt a fresh run identity that the persistence gate's first-bind-wins ownership rejected, so the retried attempt's history persisted inline before the output verdict — a denied response became durable again. The gate now accumulates every identity adopted through its own offer ticket: all attempts' persistence stays behind the one final verdict (deny drops all of it, allow flushes all of it). Accumulation over rebind-replace is deliberate: rebinding would flip an earlier attempt's still-running background work from deferred to inline, which is the fail-open direction. A foreign agent still cannot bind: tickets are minted only by the covered pipeline's final handler and adoption is instance-keyed. Also consolidates the bare-middleware-source rule into a single _as_middleware_list owner used by every interpretation site (the harness merge, BaseAgent.__init__, categorize_middleware, both client-kwargs merges, get_response, SessionContext.extend_middleware), including the str/bytes exclusion the stray copies missed. The constructor now stores a copy of the caller's sequence; assign to the middleware attribute for post-construction changes. Retry regression tests cover denied and allowed retried runs in both stream modes and fail with first-bind-wins restored. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 6, 2026 02:44 — with
GitHub Actions
Inactive
The streaming agent seam ran call_next() outside the persistence gate (only _consume entered it later), so a retry middleware that drained a successful attempt with get_final_response() and discarded it persisted that attempt's exchange before any verdict existed; a later deny dropped only the retry attempt's deferred work. The descent is now wrapped in the gate exactly like the non-streaming seam: attempt identities adopted during descent are accepted owners, so in-pipeline draining defers, deny drops every attempt, and a middleware that raises after draining strands the pending persists unexecuted. The bind_owner docstring now states the actual soundness invariant covering both bind sites: every bind comes from a run inside the covered pipeline. New tests cover drained-and-discarded attempts (deny and allow, both stream modes) and a sub-agent tool inside a drained attempt; the streaming deny variant fails with the gate wrap reverted. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 6, 2026 03:38 — with
GitHub Actions
Inactive
…ation With the pipeline descent now running inside the persistence gate, a middleware that drains a successful attempt and then terminates without a result left that attempt's deferred persistence stranded: the streaming no-result termination path raised before any flush, so history of exchanges that really happened and passed their own verdicts quietly vanished (streaming only; non-streaming already flushes before its re-raise). The path now flushes before re-raising the termination, with a state.halted guard first so an enforcement failure during the drained attempt still strands pending fail-closed and surfaces the halt, mirroring the non-streaming ordering exactly. The regression test covers both seams; the streaming variant fails without the fix. Signed-off-by: MohammadHaroonAbuomar <40180927+MohammadHaroonAbuomar@users.noreply.github.com>
MohammadHaroonAbuomar
temporarily deployed
to
github-app-auth
August 6, 2026 04:11 — with
GitHub Actions
Inactive
Evan Mattson (moonbox3)
approved these changes
Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation & Context
Runtime controls for agents (policy engines, approval flows, information-flow checks, budget guards, audit pipelines) currently require one adapter per framework, and no framework defines what happens when a guardrail callback fails or lets a control author verify "supported" claims. AGENT-HOOKS-0.1 is a framework-neutral interception contract addressing this: eight interception points, a three-verdict model (allow / deny with liftable approval / transform), fail-closed host obligations, payload-free audit records, and a conformance test kit.
PR #7444 proposed this as an external adapter package. Maintainer feedback asked for a first-class experimental feature in core instead, with a single public factory, private middleware, an opt-in extra, and corrections to transform write-back, content preservation, and streaming semantics. This PR supersedes #7444 and implements exactly that design.
Description & Review Guide
agent_framework/_agent_hooks.py: one public factory,agent_hooks_middleware(...), returning a private agent/chat/function middleware trio (one object per middleware category, percategorize_middleware()). Partial installs and stacked trios fail closed with explicit errors, so a caller cannot accidentally install part of the control contract.input/outputat the agent seam,pre/post_model_callat the chat seam,pre/post_tool_callat the function seam,agent_startup/agent_shutdownbracketing each run. Transforms write back into the native contexts (messages,arguments,results) asContentobjects; an unappliable transform raises rather than proceeding untransformed.MiddlewareTerminationshort-circuits are guarded at every seam (a substituted result passes the relevant interception point before egress); enforcement-layer failures halt the run; interceptor crashes surface ashost_errordenies.buffered_outputsemantics): no update egresses before thepost_model_call/outputverdicts; a deny at pull time releases zero updates; run state stays active across lazy pulls (ResponseStream.from_awaitable+ result/cleanup hooks) with cleanup on every exit path.emitter/builderparameters for one audit session spanning multiple runs.agent-hooks-sdkis an opt-inagent-hooksextra (not inall), lazy-imported per the_mcp.pypattern; core imports cleanly without it.ExperimentalFeature.AGENT_HOOKS+@experimental, lazy root export, typing surface,PACKAGE_STATUS.mdentry.uv lock --checkpass locally.MiddlewareTerminationguarding at the four seams; the buffered-streaming trade-off (callers get the stream API but updates arrive only after theoutputverdict — the only fully fail-closed option); tool-seam deny semantics (policy deny returns a reason-only error payload and the loop continues;host_error:*halts the run); and the sibling/stacking verification approach.Known limitation to resolve before merge:
agent-hooks-sdkon PyPI currently ships a linux-x86_64 wheel only, souv sync --all-extrasbuilds it from sdist elsewhere (macOS/Windows wheels are being published; will update this PR when live).Related Issue
Supersedes #7444 (external-adapter draft, closed in favor of this first-class design per maintainer feedback).
Contribution Checklist
Behavior change note
A bare middleware object passed to an agent constructor or assigned to the
middlewareattribute was previously ignored byrun()(any non-sequence collapsed to no middleware); it now executes, matching the per-runmiddleware=parameter semantics.